Skip to content

πŸ› dashboard: stabilize scroll across all repaint paths - #17458

Merged
hivecommons-hive[bot] merged 1 commit into
v5from
fix/dashboard-scroll-stability
Nov 9, 2026
Merged

hivecommons-hive[bot] merged 1 commit into
v5from
fix/dashboard-scroll-stability

Conversation

@clubanderson

Copy link
Copy Markdown
Member

Problem

While reading one section of the dashboard, other parts of the page grow and shrink (SSE panel updates, lazy section loads, sparkline replays, agent pane polls) and the content under the reader's eyes moves. captureScrollAnchor/restoreScrollAnchor (#11032) only wrap render() and the progressive loader; every other repaint path bypasses them. Browser-native scroll anchoring does not cover it either, since innerHTML rerenders replace the anchor node.

Fix

New static-src/js/scroll-stabilizer.js:

  • keeps one live anchor (topmost visible [data-dashboard-section], or first visible agent card by data-agent key once scrolled into the grid) β€” recaptured on user scroll (passive, rAF-coalesced) and after each correction;
  • a ResizeObserver on documentElement, body and every section calls the corrector after layout, before paint, so the fix lands in the same frame as the shift and nothing visibly moves;
  • correction is delta-based against the persistent node, so it composes with native scroll anchoring instead of double-counting;
  • a MutationObserver picks up lazily rendered sections (observe() is idempotent);
  • defers to _hashScrollPending and an active ocSettleScroll so hash/sidebar navigation is never fought; viewport resize re-measures instead of correcting; no-op at scrollY <= 0.

index.html regenerated with make dashboard-gen.

Verification

  • go test ./pkg/dashboard/... β€” webstatic/gen pass (TestIndexHTMLIsGenerated green); the only failure in pkg/dashboard is the pre-existing TestKnowledgeDrainLegacyMemory macOS /var symlink issue, unrelated.
  • All inline scripts in the generated document parse.

Content above the viewport changes height on SSE panel updates, lazy
section loads, sparkline replays and pane polls, not only on render(),
so the reader's section drifted up or down. Add a standalone scroll
stabilizer: a ResizeObserver on the document and every
[data-dashboard-section] re-applies a continuously maintained anchor
(same shape as captureScrollAnchor) after layout and before paint, so
the correction lands in the frame of the shift. Delta-based against a
persistent node, it composes with native scroll anchoring and defers to
hash/sidebar navigation settling.

Signed-off-by: Andrew Anderson <andy@clubanderson.com>
@kubestellar-prow kubestellar-prow Bot added the dco-signoff: yes Indicates the PR's author has signed the DCO. label Nov 9, 2026
@kubestellar-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign clubanderson for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubestellar-prow kubestellar-prow Bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Open PR overlap check

Base branch: v5

Conflicts are checked with every PR merged onto the current tip of v5, so a PR that only needs a rebase is not reported as conflicting with its siblings.

PR Title Relation Files
#17457 πŸ› activity monitor: light a needs-decision PR's real stage and carry the ask as a pill same files src/pkg/dashboard/static/index.html

@hivecommons-hive hivecommons-hive Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/maintainability review at head fecae86 (read static-src/js/scroll-stabilizer.js, refresh.js, agent-detail.js, progressive.js, and the existing scroll tests).

1. No pinning test, unlike every prior scroll fix in this tree β€” src/pkg/dashboard/static-src/js/scroll-stabilizer.js:1-110
The repo idiom for dashboard scroll behaviour is a jsFunc-snippet Go test that pins the mechanism (agents_refresh_scroll_jump_test.go:22-43 for the card anchor, sidebar_nav_scroll_settle_test.go for ocSettleScroll). This PR adds 110 lines of scroll-correction logic and no such pin, so a future edit can silently drop the _hashScrollPending/_ocScrollSettleCancel deferral or the _ssSelfScroll guard. Suggested src/pkg/dashboard/scroll_stabilizer_test.go:

+package dashboard
+
+import (
+	"strings"
+	"testing"
+)
+
+// TestScrollStabilizerDefersToNavigation pins the ResizeObserver-driven
+// stabilizer: it must never fight a settling hash/sidebar navigation, and
+// must not treat its own scrollBy as a reader scroll.
+func TestScrollStabilizerDefersToNavigation(t *testing.T) {
+	html := indexHTML(t)
+	suppressed := jsFunc(t, html, "_ssSuppressed")
+	for _, snippet := range []string{"_hashScrollPending) return true;", "_ocScrollSettleCancel) return true;"} {
+		if !strings.Contains(suppressed, snippet) {
+			t.Errorf("_ssSuppressed is missing %q", snippet)
+		}
+	}
+	correct := jsFunc(t, html, "_ssCorrect")
+	for _, snippet := range []string{"if (_ssSuppressed() || !_ssAnchor || window.scrollY <= 0)", "_ssSelfScroll = true;", "window.scrollBy(0, delta);"} {
+		if !strings.Contains(correct, snippet) {
+			t.Errorf("_ssCorrect is missing %q", snippet)
+		}
+	}
+	init := jsFunc(t, html, "initScrollStabilizer")
+	if !strings.Contains(init, "new ResizeObserver(_ssCorrect)") {
+		t.Error("initScrollStabilizer no longer drives correction from a ResizeObserver")
+	}
+}

2. _ssDrift re-implements restoreScrollAnchor's lookup β€” scroll-stabilizer.js:45-57 vs refresh.js:441-457
The CSS.escape fallback, .agent-card[data-agent="…"] query and getClientRects().length checks are copied verbatim. The two will drift apart (e.g. if the card selector changes). Extract the delta into one helper in refresh.js and have both callers use it:

--- a/src/pkg/dashboard/static-src/js/refresh.js
+++ b/src/pkg/dashboard/static-src/js/refresh.js
-    function restoreScrollAnchor(anchor) {
+    // Vertical distance the anchor has moved since capture, or null when the
+    // anchored node is gone and the caller must fall back to scrollY.
+    function scrollAnchorDrift(anchor) {
       if (anchor.cardKey && anchor.el && anchor.el.isConnected) {
         const safeKey = (window.CSS && CSS.escape) ? CSS.escape(anchor.cardKey) : String(anchor.cardKey).replace(/["\\]/g, '\\$&');
         const card = anchor.el.querySelector(`.agent-card[data-agent="${safeKey}"]`);
-        if (card && card.getClientRects().length) {
-          const delta = card.getBoundingClientRect().top - anchor.cardTop;
-          if (delta) window.scrollBy(0, delta);
-          return;
-        }
+        if (card && card.getClientRects().length) return card.getBoundingClientRect().top - anchor.cardTop;
       }
       if (anchor.el && anchor.el.isConnected && anchor.el.getClientRects().length) {
-        const delta = anchor.el.getBoundingClientRect().top - anchor.top;
-        if (delta) window.scrollBy(0, delta);
-        return;
+        return anchor.el.getBoundingClientRect().top - anchor.top;
       }
-      window.scrollTo(0, anchor.scrollY);
+      return null;
+    }
+
+    function restoreScrollAnchor(anchor) {
+      const delta = scrollAnchorDrift(anchor);
+      if (delta === null) { window.scrollTo(0, anchor.scrollY); return; }
+      if (delta) window.scrollBy(0, delta);
     }
--- a/src/pkg/dashboard/static-src/js/scroll-stabilizer.js
+++ b/src/pkg/dashboard/static-src/js/scroll-stabilizer.js
-    // Returns the vertical delta the anchor has drifted by, or 0.
-    function _ssDrift(anchor) {
-      if (!anchor) return 0;
-      if (anchor.cardKey && anchor.el && anchor.el.isConnected) {
-        const safeKey = (window.CSS && CSS.escape) ? CSS.escape(anchor.cardKey) : String(anchor.cardKey).replace(/["\\]/g, '\\$&');
-        const card = anchor.el.querySelector(`.agent-card[data-agent="${safeKey}"]`);
-        if (card && card.getClientRects().length) return card.getBoundingClientRect().top - anchor.cardTop;
-      }
-      if (anchor.el && anchor.el.isConnected && anchor.el.getClientRects().length) {
-        return anchor.el.getBoundingClientRect().top - anchor.top;
-      }
-      return 0;
-    }
+    // Returns the vertical delta the anchor has drifted by, or 0 when the
+    // anchored node is gone (the next capture will pick a new one).
+    function _ssDrift(anchor) {
+      if (!anchor) return 0;
+      return scrollAnchorDrift(anchor) || 0;
+    }

Both changes need make dashboard-gen to regenerate static/index.html. Everything else (typeof guards matching progressive.js:140, _ss prefix, ── ── header, DOMContentLoaded gate matching layout.js:325) follows existing conventions.

β€” hive: agent=ci-maintainer backend=copilot model=claude-fable-5.1 copilot=1.0.88

@hivecommons-hive

hivecommons-hive Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Hive status β€” updated in place; edits do not notify. Full history is in the Activity Monitor.

Review findings from adjudicator-3 (folded here because this head already carries its hive review):

Intent-alignment review (head fecae86)

The body says the stabilizer "defers to _hashScrollPending and an active ocSettleScroll so hash/sidebar navigation is never fought". Those are the only two guards in _ssSuppressed() (static-src/js/scroll-stabilizer.js:24-30), but three existing navigation paths set neither:

  • scrollToAgentCard β€” static-src/js/agent-detail.js:122 (card.scrollIntoView({ block: 'center', behavior: 'smooth' })), reached from openAgentCollapsedTile (:144-145) via ocAfterNavLayoutSettles
  • scrollToRepoCard β€” agent-detail.js:152
  • openAdvisoryAdvicePill β€” agent-detail.js:190

ocAfterNavLayoutSettles finishes through its own local double-rAF afterPaint (agent-detail.js:58, :78), not _afterPaint in refresh.js:151, so _hashScrollPending is never raised on these paths, and none of them go through ocSettleScroll, so _ocScrollSettleCancel stays null. During the resulting smooth scroll (a few hundred ms), any resize of documentElement/body/a section β€” e.g. an agent-card terminal tail growing on SSE, which is the very case this PR targets β€” runs _ssCorrect, and window.scrollBy(0, delta) (scroll-stabilizer.js:64) cancels the browser's in-flight smooth scroll. The reader lands partway to the card they clicked. Before this PR nothing issued programmatic scrolls on those paths outside render(), so this is a regression the new observer introduces.

Suggested fix β€” route the three through the existing settle loop so the stabilizer's guard sees them (shown for scrollToAgentCard; same shape for the other two):

--- a/src/pkg/dashboard/static-src/js/agent-detail.js
+++ b/src/pkg/dashboard/static-src/js/agent-detail.js
@@ function scrollToAgentCard(name, behavior) {
       const card = document.querySelector(selector);
       if (!card) return false;
-      card.scrollIntoView({ block: 'center', behavior: behavior || 'smooth' });
+      const targetY = () => card.getBoundingClientRect().top + window.scrollY - Math.max(0, (window.innerHeight - card.offsetHeight) / 2);
+      ocSettleScroll(targetY, () => card.scrollIntoView({ block: 'center', behavior: behavior || 'smooth' }), OC_NAV_SETTLE_MS, card);
       card.classList.remove('anchor-highlight');

Passing card as anchorEl also makes captureScrollAnchor (refresh.js:413-417) pin the navigated-to card during the hold, which is what the sidebar-section path already does at agent-detail.js:112.

Not verified by running the dashboard; inferred from the frame ordering (scroll event β†’ rAF β†’ layout β†’ ResizeObserver) and the fact that any programmatic scroll aborts a pending smooth scroll in Chromium/Gecko. If the author has observed these paths staying smooth under SSE load with this branch, I'd take that as evidence against this.

Review findings from adjudicator (folded here because this head already carries its hive review):

Correctness pass on fecae86. Sections are persistent containers (index.html:8391-8417), so el.isConnected holds and the delta math composes with render()'s own capture/restore (index.html:47236-47238, 9393-9404) β€” no double-correction there. One gap in the mechanism itself:

Deferred recapture absorbs back-to-back shifts (medium) β€” scroll-stabilizer.js:70-74 (_ssCorrect) and :40-44 (_ssQueueCapture). After every ResizeObserver callback (and every scroll event) the anchor is re-captured in a requestAnimationFrame. In the rendering update, rAF callbacks run before style/layout and before ResizeObserver notifications. So: frame N RO fires β†’ capture queued; an SSE panel update / pane poll mutates the DOM in a task; frame N+1 rAF _ssCapture reads the post-shift layout β†’ RO then sees drift 0 and does nothing. The reader sees the jump. Any repaint that lands within one frame of a previous resize or a user scroll is uncorrected β€” exactly the burst case (status render followed by lazy panels / pane polls) the PR body describes. Isolated shifts with a quiet frame before them work as advertised.

Fix: re-capture synchronously inside the RO callback. Layout is already clean there and scrollBy is instant (no scroll-behavior: smooth on the page), so the read is cheap and coherent:

     function _ssCorrect() {
-      if (_ssSuppressed() || !_ssAnchor || window.scrollY <= 0) { _ssQueueCapture(); return; }
+      if (_ssSuppressed() || !_ssAnchor || window.scrollY <= 0) { _ssCapture(); return; }
       const delta = _ssDrift(_ssAnchor);
       if (Math.abs(delta) >= SCROLL_STABILIZER_MIN_DELTA_PX) {
         const before = window.scrollY;
         _ssSelfScroll = true;
         window.scrollBy(0, delta);
         // Clamped at an edge: no scroll event will follow, so clear the flag.
         if (window.scrollY === before) _ssSelfScroll = false;
       }
-      // Content may have changed which section is topmost; refresh the
-      // anchor so the next shift is measured from where the reader is now.
-      _ssQueueCapture();
+      // Content may have changed which section is topmost; refresh the
+      // anchor now (layout is clean inside the RO callback) so a shift that
+      // lands before the next frame is still measured against this position.
+      _ssCapture();
     }

Keep the rAF-coalesced path for the scroll listener; that one is fine.

Not verified by test: there is no JS harness for this behaviour β€” only TestIndexHTMLIsGenerated covers the file, so the above is from event-loop ordering, not a reproduction.

Review findings from reviewer-2 (folded here because this head already carries its hive review):

Read scroll-stabilizer.js against refresh.js (captureScrollAnchor/restoreScrollAnchor, render()), agent-detail.js (ocSettleScroll, _ocScrollSettleCancel, _ocNavAnchorEl) and progressive.js. The core mechanism is sound: the scroll event β†’ rAF recapture lands before the ResizeObserver callback in the same frame, so the animation's own movement is never counted as drift, and after render()'s synchronous restoreScrollAnchor the stabilizer measures ~0 drift against the same node, so the two do compose rather than double-correct. Scope/ordering is fine too β€” everything is one <script> (manifest.txt lines 59–147) and the file sits after agent-detail.js/refresh.js, so the typeof guards never hit a TDZ.

One behavioural interaction worth a decision before this lands:

Un-wrapped smooth scrolls get aborted mid-flight (medium). _ssSuppressed() (scroll-stabilizer.js:24-29) only defers to _hashScrollPending and _ocScrollSettleCancel. Only ocScrollSectionIntoView and ocScrollToAgentDetail go through ocSettleScroll (agent-detail.js:112, :426). The other programmatic smooth scrolls β€” scrollToAgentCard (agent-detail.js:122, :152), agent-detail.js:190, tracker-actions.js:1122, tracker-merged.js:837, :1354, vault-audit.js:249, version.js:958, welcome.js:92 β€” are not suppressed. While one of those animates, each frame's scroll event recaptures the anchor at the intermediate position; if any observed element then resizes with content above that anchor (an SSE render, pane poll β€” exactly the events this PR targets), _ssCorrect calls window.scrollBy(0, delta) (scroll-stabilizer.js:64), and a new scroll operation aborts the in-progress smooth scroll per CSSOM View. Result: click a tracker row / agent card, a repaint happens 200 ms in, and the page stops short of the target instead of arriving (the previous behaviour was "arrives, possibly at a stale Y"). Same applies to the browser's own animated wheel scrolling in Chromium. Timing-dependent, so I could not reproduce from a static read β€” but the dashboard repaints every few seconds, so it is not an edge case. Options: route those callers through ocSettleScroll (which already handles drift for its own targets), or have the stabilizer treat scroll as user-driven only after a wheel/touchstart/keydown like OC_SCROLL_CANCEL_EVENTS does.

Not a defect, but a convention gap: this repo pins its scroll JS with structural Go tests (agent_scroll_anchor_test.go, agents_refresh_scroll_jump_test.go β€” presence of the helper, its constants, the passive listener). This PR adds 110 lines of browser-only logic with no equivalent test, so a future edit to _ssSuppressed or the { passive: true } listener would regress silently. A TestScrollStabilizerPresent in the same style would cover it.

Body matches the diff (no claims beyond what scroll-stabilizer.js does). index.html regeneration is covered by TestIndexHTMLIsGenerated in CI.

Review findings from reviewer (folded here because this head already carries its hive review):

docs-currency (read at head fecae86)

The new js/scroll-stabilizer.js part is added to static-src/manifest.txt:120 but not to the JS area list in src/docs/dashboard-source-layout.md, which enumerates every js/<area>.js part in manifest order (line 224 lists refresh, inception, …). The layout doc is the map contributors use to find which part owns a behavior; leaving the stabilizer out means the one place the scroll-pinning logic now lives isn't discoverable there.

src/docs/dashboard-source-layout.md:224

`review-queue`, `contributors`, `refresh`, `scroll-stabilizer`, `inception`, `debug-logs`,

Everything else checked out: the anchor shape (el/top/cardKey/cardTop) matches refresh.js:408, _ocScrollSettleCancel is a script-scope let in agent-detail.js:378 (manifest line 65, before this part), the changelog fragment follows changelog.d/README.md format, and no existing doc describes the scroll-anchor design in a way this PR makes stale.

Evidence bundle `#17458fecae86` sha256:7b4fb8e0d09ac0d33ec38c8742b49af50cceaaf842c08f89481bcaefa8ea0eeb β€” download from the dashboard or `hivectl review evidence #17458`.

@hivecommons-hive
hivecommons-hive Bot merged commit 0c807ec into v5 Nov 9, 2026
86 of 91 checks passed
@kubestellar-prow
kubestellar-prow Bot deleted the fix/dashboard-scroll-stability branch November 9, 2026 22:20
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Thank you for your contribution! Your PR has been merged.

We'd love to hear how your experience was: share feedback

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the DCO. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant